iT邦幫忙

2022 iThome 鐵人賽

DAY 17
1
Software Development

30天成為鍵盤麥可貝:前端視覺特效開發實戰系列 第 17

Day17: three.js GIS系統開發實戰:鄉鎮市區GIS系統:SVG、GeoJson的應用

  • 分享至 

  • xImage
  •  

前言

網頁視覺特效有一大塊領域在於GIS。GIS以視覺呈現地理資訊,視覺是不可或缺的元素。透過視覺特效,能夠更快速的釐清地理環境狀況。

例如Districgraphic.tw,就使用鄉鎮市區為最小單位,去呈現資料。

Untitled

https://ithelp.ithome.com.tw/upload/images/20221002/20142505curEM77KeS.png

而對於GIS類型的專案來說,最重要的工作,不外乎來源資料的格式轉換。來源資料可能有幾種:

  1. 多個座標的資料:

    1. 例如:例如青少年犯罪地點分佈圖。
    2. 常用格式:GeoJson、SHP and SHX。
  2. 交通資料:

    1. 例如:物流公司最佳路線規劃。

    2. 常用格式:GeoJson

      https://ithelp.ithome.com.tw/upload/images/20221002/20142505O7q2oZJtxe.png

  3. 圖磚:使用地圖圖資(靜態圖磚Static Tile)

    1. 例如:鐵路工程地層下陷分析。
    2. 常用格式:png或是geoJson

    Untitled

    Untitled

  4. 模型資料:

    1. 例如:預載好的建築物、地形
    2. 常用格式:gltf檔、svg、json、png等多種。

    https://ithelp.ithome.com.tw/upload/images/20221002/20142505wfeLT6VByk.png

  5. 時間、位置跟角度:

    1. 例如:空拍機固定路線可以掃到的區域,大樓阻擋的投影範圍
    2. 常用格式:時間、三維座標
  6. 點陣資料:

    1. 例如:氣象雲圖、降雨量圖
    2. 常用格式:jpg或png等

    https://ithelp.ithome.com.tw/upload/images/20221002/20142505gypVGIi1LK.jpg

    下圖使用上圖作為來源,渲染立體效果。

    Untitled

  7. 錨點資料:

    1. 例如:呈現特定交通路徑
    2. 常用格式:座標、GeoJson

以及非常多應用方式。

以上這些,three.js都辦得到。我這邊以SVG來生成模型,由於SVG為標籤語言,我們可以看到錨點資料,而且SVG為廣用的格式。雖然如此,SVG在匯入到three.js時仍然有很多眉角需要注意。

我將介紹以下內容:

  1. SVG渲染成3D物件的流程說明
  2. SVGLoader的使用方法
  3. three.js的Loader其異步讀入方法說明
  4. shapePath說明
  5. Path轉成3D Mesh時的效能問題
  6. SVG的方位問題
  7. 從SVG讀入CSS樣式,並在3D物件中填色
  8. 從SVG讀入ID值
  9. 處理extrude時的normal問題

由SVG渲染模型

流程說明

前一篇提到extrude,本篇亦同,然而實作的方向不太相同。

製作圓餅圖時,我們從原始資料轉成角度,再轉成弧度。有了弧度之後,製作出EllipseCurve,再轉成Vector2,再轉成Shape,再轉成ExtrudeGeometry。見圖:

https://ithelp.ithome.com.tw/upload/images/20221002/20142505rlViHQqkxb.png

本次實作中,我們由SVG出發,由SVGLoader讀入之後,會是一個ShapePath格式。透過SVGLoader.createShpes()後,就能夠得到shape物件,最終Extrude成物件。

https://ithelp.ithome.com.tw/upload/images/20221002/20142505jNrXph0auY.png

不管是怎樣的讀入方式,如果要渲染到畫面上,就得要挑一個geometry來產生3D物件,在這之前,一切都只是描述形狀資料而已。

準備程式碼

首先,我們先透過three.js範本開一個新的專案。我們直接沿用這個空的three.js範本即可:

CodePen

https://ithelp.ithome.com.tw/upload/images/20221002/20142505csLkXUG4O0.png

https://codepen.io/umas-sunavan/pen/WNJJXXK?editors=0010

原始碼

  1. 複製貼上的index.js,然後開一個html來引用即可。可以參考Codepen。

    import * as THREE from 'three';
    import { OrbitControls } from 'https://unpkg.com/three@latest/examples/jsm/controls/OrbitControls.js';
    
    const renderer = new THREE.WebGLRenderer();
    renderer.setSize(window.innerWidth, window.innerHeight);
    document.body.appendChild( renderer.domElement );
    
    const scene = new THREE.Scene();
    scene.background = new THREE.Color(0xf2f2f2)
    const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
    camera.position.set(0, -500, 900)
    // 在camera, renderer宣告後之後加上這行
    const control = new OrbitControls(camera, renderer.domElement);
    
    control.target.set(250,-250,0)
    control.update()
    
    // 新增環境光
    const addAmbientLight = () => {
    	const light = new THREE.AmbientLight(0xffffff, 0.6)
    	scene.add(light)
    }
    
    // 新增平行光
    const addDirectionalLight = () => {
    	const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8)
    	directionalLight.position.set(20, 20, 20)
    	scene.add(directionalLight);
    	directionalLight.castShadow = true
    	const d = 10;
    
    	directionalLight.shadow.camera.left = - d;
    	directionalLight.shadow.camera.right = d;
    	directionalLight.shadow.camera.top = d;
    	directionalLight.shadow.camera.bottom = - d;
    
    	// 新增Helper
    	const lightHelper = new THREE.DirectionalLightHelper(directionalLight, 20, 0xffff00)
    	scene.add(lightHelper);
    	// 更新位置
    	directionalLight.target.position.set(0, 0, 0);
    	directionalLight.target.updateMatrixWorld();
    	// 更新Helper
    	lightHelper.update();
    }
    
    addAmbientLight()
    addDirectionalLight()
    
    function animate() {
    	requestAnimationFrame( animate );
    	renderer.render( scene, camera );
    }
    animate();
    
  2. 加上原始資料,用來呈現不同區域的高度

    // 假設GIS來源資料如下
    const data = [
    	{ rate: 14.2, name: '雲嘉' },
    	{ rate: 32.5, name: '中彰投' },
    	{ rate: 9.6, name: '南高屏' },
    	{ rate: 9.7, name: '宜花東' },
    	{ rate: 21.6, name: '北北基' },
    	{ rate: 3.4, name: '桃竹苗' },
    	{ rate: 9.0, name: '澎湖' },
    ]
    

讀取SVG

讀取SVG:讀取物件

加入SVGLoader(),使用函式.load().loadAsync()匯入模型檔。

import { SVGLoader } from 'https://unpkg.com/three@latest/examples/jsm/loaders/SVGLoader.js';

const loadPathsFromSvg = () => {
	const loader = new SVGLoader();
	loader.load('taiwan.svg', svgData => {
		console.log(svgData);
	})
}

loadPathsFromSvg()

https://ithelp.ithome.com.tw/upload/images/20221002/20142505q4ERCaKepA.png

補充說明:.loadAsync().load()差異

到目前為止,我們使用了TextrueLoader, SVGLoader, FontLoader等各種loader。three.js存在各種loader,提供我們多種資料的匯入。

Loader必須手動處理異步嗎?

所有的Loader,提供函式:

  • Loader.load( path, callback)

其中,path 代表路徑,callback代表讀取完之後的回呼函式。事實上還有失敗的回呼函式、讀取中進度條的回呼函式等多種,依照Loader而定。

這代表如果我們需要讓異步變成同步,必須手動包Promise才行:

const loadPathsFromSvg = async () => {
	const loader = new SVGLoader();
	// 手動用Proise把異步包成同步
	return new Promise( (res, rej) => {
		loader.load('taiwan.svg', svgData => {
			res(svgData)
		})
	})
}

一些網路教學就使用這個方式開發。

你不需要自己包Promise

但事實上,所有的Loader也都提供loadAsync()

  • Loader.loadAsync( path )

而回傳的即為一個Promise。所以說,可以loader的函式可以這樣撰寫:

const loadPathsFromSvg = async () => {
	const loader = new SVGLoader();
	const data = await loader.loadAsync('taiwan.svg')
}

甚至一行搞定:

const loadPathsFromSvg = async () => await new SVGLoader().loadAsync('taiwan.svg');

所以說,可以善用loadAsync(),使得異步程式碼更加簡潔

讀取SVG:改用loadAsync讀取

我將load()改成loadAsync(),並且讀取svg資料中的paths

import { SVGLoader } from 'https://unpkg.com/three@latest/examples/jsm/loaders/SVGLoader.js';

const loadPathsFromSvg = async () => await new SVGLoader().loadAsync('taiwan.svg');

(async () => {
	const svgData = await loadPathsFromSvg()
	const paths = svgData.paths
	console.log(paths);
})()

可以看到,我們讀取很多的shapePath,而這些shapePath 是什麼?又是從哪來的?

https://ithelp.ithome.com.tw/upload/images/20221002/20142505cRK8VqMCJh.png

讀取SVG:shapePath是什麼?

shapePath 是一種能夠儲存多個shape的Path。能透過.toShape() 或是 SVGLoader.createShapes() 來轉成Shape

https://ithelp.ithome.com.tw/upload/images/20221002/20142505X9Pf8ORfeb.png

讀取SVG:shapePath 從哪裡來的?

如果我們打開taiwan.svg,會發現有幾個<path>

<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
	 viewBox="0 0 538 540" style="enable-background:new 0 0 538 540;" xml:space="preserve">
    <g id="澎湖">
        <path d="..."/>
        <path d="..."/>
        <path d="..."/>
        <path d="..."/>
        <path d="..."/>
        <path d="..."/>
        <path d="..."/>
        <path d="..."/>
        <path d="..."/>
        <path d="..."/>
    </g>
    <g id="宜花東">
        <path d="..."/>
        <path d="..."/>
    </g>
    <path id="南高屏" d="..."/>
    <path id="雲嘉" d="..."/>
    <path id="中彰投" d="..."/>
    <path id="桃竹苗" d="..."/>
    <path id="北北基" d="..."/>
</svg>

SVGLoader遍歷所有路徑,並且變成shapePath陣列。同時儲存顏色、Id、class等基本屬性,而這就夠我們使用了。

將Path轉成3D Mesh

https://ithelp.ithome.com.tw/upload/images/20221002/20142505eLuLyLQQVZ.png

依照流程圖,我們只要透過SVGLoader.createShape() 即可將ShapePath轉成Shape,然後放到extrudeGeometry 中。

我們遞迴每一個path,透過SVGLoader.createShapes(); 產生出shape,並轉成geometry,最終實例化Shape 成為Mesh,加到場景中


(async () => {
	const svgData = await loadPathsFromSvg()
	const paths = svgData.paths
	const group = new THREE.Group();
	// 遞迴paths
	paths.forEach( path => {
		const shapes = SVGLoader.createShapes(path);
		const material = new THREE.MeshStandardMaterial();	
		shapes.forEach( shape => {
			const geometry = new THREE.ExtrudeGeometry(shape);
			const mesh = new THREE.Mesh(geometry, material);
			// 將所有可渲染的Mesh加入成群組物件group
			group.add(mesh);
		})
	})
	// 顯示群組物件,就可以顯示群組底下的物件
	scene.add(group);
})()

https://ithelp.ithome.com.tw/upload/images/20221002/20142505Q5SpYh3cV3.png

可以看到台灣已經跑出來了。

但現在遇到幾個問題:

  1. 操作非常卡頓
  2. 台灣是倒過來的
  3. 各地區都是白色,看不出差異。

我們一個一個解決:

將Path轉成3D Mesh:效能問題

畫面相當卡頓,如果我們把materialwireframe打開,可以看到這個SVG由相當多的點所組成。

https://ithelp.ithome.com.tw/upload/images/20221002/20142505VPoBzthSGP.png

這造成很大的效能問題。為了解決這個問題,我們可以透過extrude的設定中,簡化路徑:

// const geometry = new THREE.ExtrudeGeometry(shape);
// 取消bevel、steps設成1
const geometry = new THREE.ExtrudeGeometry(shape, {
		steps: 1,
		bevelEnabled: false
});

將Path轉成3D Mesh:為什麼台灣是倒過來的?

之所以台灣是倒過來的,是因為SVG跟Three.js相容性問題,以下解釋:

Three.js的(0,0)原點為左下,而SVG的(0,0)原點為左上,導致在SVGLoader在匯入時,會顛倒。

https://ithelp.ithome.com.tw/upload/images/20221115/2014250540nRVeVGH3.jpg

為了解決這個問題,我們只要用.rotateX(Math.PI)把整個畫面旋轉即可。又或者將Perspectiveamera.up設置成-1 ,使得鏡頭方向上下顛倒。

(async () => {
	const svgData = await loadPathsFromSvg()
	const paths = svgData.paths
	const group = new THREE.Group();
	// 遞迴時,已經把台灣所有地區加入到group裡面
	paths.forEach( ... )
	// 旋轉3D物件即可
	group.rotateX(Math.PI)
	scene.add(group);
})()

https://ithelp.ithome.com.tw/upload/images/20221002/20142505vlz7Gtdcza.png

設定之後,顛倒問題即解決。

將Path轉成3D Mesh:用顏色區分地區

每一個path,除了儲存id值、class值、路徑資料以外,還有它所儲存的CSS資料。如果我們看到SVG,可以發現它存有顏色,並統一在<style/>裡面管理了。

<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
	 viewBox="0 0 538 540" style="enable-background:new 0 0 538 540;" xml:space="preserve">
    <style type="text/css">
        .st0{fill:#D6DE23;}
        .st1{fill:#D0E299;}
        .st2{fill:#C6E9FA;}
        .st3{fill:#FFF100;}
        .st4{fill:#CE8A2D;}
        .st5{fill:#CCDDE6;}
    </style>
    <g id="澎湖">
        <path class="st0" d="..."/>
        <path class="st0" d="..."/>
        <path class="st0" d="..."/>
        <path class="st0" d="..."/>
        <path class="st0" d="..."/>
        <path class="st0" d="..."/>
        <path class="st0" d="..."/>
        <path class="st0" d="..."/>
        <path class="st0" d="..."/>
        <path class="st1" d="..."/>
    </g>
    <g id="宜花東">
        <path class="st1" d="..."/>
        <path class="st1" d="..."/>
    </g>
    <path id="南高屏" class="st2" d="..."/>
    <path id="雲嘉" class="st0" d="..."/>
    <path id="中彰投" class="st3" d="..."/>
    <path id="桃竹苗" class="st4" d="..."/>
    <path id="北北基" class="st5" d="..."/>
</svg>

樣式可能存在style,也可能存在行內屬性。無論如何,SVGLoader都可以輕易抓到樣式資料,相當方便,我們只要將顏色放到material即可。

// const material = new THREE.MeshStandardMaterial();
const color = path.color
const material = new THREE.MeshStandardMaterial({color});

https://ithelp.ithome.com.tw/upload/images/20221002/20142505vaq5D98rcG.png

將數值轉成物件高度

這個作法已經有在上一篇「Day16: three.js 前端3D視覺特效開發實戰——3D儀表板:立體圓餅圖、extrude在three獨特差異」已經有提到。

將數值轉成物件高度:抓取SVG中的ID

然而不同的是,我們得在SVG裡面抓到ID值,然後對照在數據資料,藉此找到對應3D物件,調整其高度。

// 既有的數據資料
const data = [
	{ rate: 14.2, name: '雲嘉' },
	{ rate: 32.5, name: '中彰投' },
	{ rate: 9.6, name: '南高屏' },
	{ rate: 9.7, name: '宜花東' },
	{ rate: 21.6, name: '北北基' },
	{ rate: 3.4, name: '桃竹苗' },
	{ rate: 9.0, name: '澎湖' },
]

...

const color = path.color
// 除了顏色以外,我們還可以抓到id值
const name = path.userData.node.id
// 抓到ID值之後,對照數據資料
const dataRaw = data.find(row => row.name === name)

將數值轉成物件高度:抓取Group中的ID

這個作法會有一個問題:如果path位在群組,那麼將抓不到群組ID

回顧我們的svg檔案,由於澎湖、宜花東有離島,導致它透過群組來命名

<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
	 viewBox="0 0 538 540" style="enable-background:new 0 0 538 540;" xml:space="preserve">
    <g id="澎湖">
        <path class="st0" d="..."/>
        <path class="st0" d="..."/>
        <path class="st0" d="..."/>
        <path class="st0" d="..."/>
        <path class="st0" d="..."/>
        <path class="st0" d="..."/>
        <path class="st0" d="..."/>
        <path class="st0" d="..."/>
        <path class="st0" d="..."/>
        <path class="st1" d="..."/>
    </g>
    <g id="宜花東">
        <path class="st1" d="..."/>
        <path class="st1" d="..."/>
    </g>
    <path id="南高屏" class="st2" d="..."/>
    <path id="雲嘉" class="st0" d="..."/>
    <path id="中彰投" class="st3" d="..."/>
    <path id="桃竹苗" class="st4" d="..."/>
    <path id="北北基" class="st5" d="..."/>
</svg>

舉例來說,澎湖有很多離島,每一個離島都是一個Path。我們該怎麼確定哪些path是澎湖呢?

如果沒有額外處理,那麼這些存在群組內的path是抓不到id值的。而我處理的方式,就是抓取parent的ID值。

- const name = path.userData.node.id
+ const parentName = path.userData.node.parentNode.id;
+ const name = path.userData.node.id || parentName

如此一來,就可以替每一個mesh抓到正確的名字,進而賦予高度的數值。

https://ithelp.ithome.com.tw/upload/images/20221002/20142505FQiClvoK24.png

將數值轉成物件高度:賦予高度值

修改ExtrudeGeometry的參數即可。

-   const geometry = new THREE.ExtrudeGeometry(shape, {
-   		steps: 1,
-   		bevelEnabled: false
-   });
+   // 由ID抓取對應的數據資料,作為高度
+   const dataRaw = data.find(row => row.name === name)
+   const geometry = new THREE.ExtrudeGeometry(shape, {
+       depth: dataRaw.rate,
+       steps: 1,
+       bevelEnabled: false
+   });

修改後,我們的模型就有了高度:

https://ithelp.ithome.com.tw/upload/images/20221002/20142505o89Vl0qGdf.png

https://ithelp.ithome.com.tw/upload/images/20221002/20142505K4OtoyfYdk.png

你會看到,它Extrude的方向跟我們想像的不同。

將數值轉成物件高度:修改Extrude方向

由於我們把模型用group.rotateX(Math.PI)翻到了背面,雖然這麼做使得台灣是面向鏡頭的,但實際上extrude是往背面長出來的。

為了解決問題,將depth改成負值即可:


const geometry = new THREE.ExtrudeGeometry(shape, {
-	depth: dataRaw.rate,
+	depth: -dataRaw.rate,
	steps: 1,
	bevelEnabled: false
});

但又出現一個問題:雖然extrude方向雖然面對了鏡頭,但normal方向仍然錯誤。

https://ithelp.ithome.com.tw/upload/images/20221002/20142505tyw5aTlCEw.png

https://ithelp.ithome.com.tw/upload/images/20221002/201425050PQMgyWIWU.png

由圖可知,由於normal方向往內,導至畫面怪怪的。

為什麼normal跟這個有關?

一般來說,我們只看得到normal的正面,它的背面是不會自動渲染的。這個在Maya, 3ds Max, Blender, babylon.js都一樣,是普遍的預設渲染方式。畢竟我們看不到

polygon-drawing-order.gif

圖片來源

此這個動畫來看,你會發現,在「F」內側的面根本不沒要渲染,因為用戶根本就不會看到那面。(這張圖僅作為示意圖,實際上渲染順序不會是這樣)

為了節省渲染效能,3D引擎往往就得判斷該面是否為normal的正面。如果是背面就不用渲染。

因此,我們可以設定物件只渲染背面,如此一來就能夠正常顯示畫面。

  const color = path.color
- const material = new THREE.MeshStandardMaterial({color});
+ const material = new THREE.MeshStandardMaterial({color,side: THREE.BackSide,});

一個台灣的高度圖就做出來了。

完成品

https://ithelp.ithome.com.tw/upload/images/20221002/20142505sl6m1tyXUo.png

Untitled

CodePen

https://ithelp.ithome.com.tw/upload/images/20221002/201425057ccJlUa2wa.png

https://codepen.io/umas-sunavan/pen/eYrrVvy?editors=0010

小結

多虧先前圓餅圖的練習,我們能夠很快速的建立3D物件。有了SVG讀入的功能,不僅可以自己透過SVG製作模型,也可以透過three.js製作3D的GIS網站。

如果有興趣認識更多這類GIS地圖的開發,可以到Districgraphic.tw,或是geostat.tw觀察原始碼。

Untitled

Untitled

參考資料

SVG顛倒時的討論

渲染順序

地理資訊地圖

台灣雲圖


上一篇
Day16: three.js 3D圖表特效開發實戰:你爹只給個爛餅,大不了還你3D爛餅:用粉圓體做立體圓餅圖
下一篇
Day18: three.js GIS系統開發實戰:成為網頁特效的鹿丸!影子模仿術:陰影的終極原理
系列文
30天成為鍵盤麥可貝:前端視覺特效開發實戰31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言